Skip to content

feat(langgraph-ts): AG-UI StreamTransformer (v3 protocol path) for all demos - #1651

Open
ranst91 wants to merge 52 commits into
mainfrom
claude/beautiful-curran-d22bd3
Open

feat(langgraph-ts): AG-UI StreamTransformer (v3 protocol path) for all demos#1651
ranst91 wants to merge 52 commits into
mainfrom
claude/beautiful-curran-d22bd3

Conversation

@ranst91

@ranst91 ranst91 commented May 11, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds an opt-in v3-protocol streaming path to @ag-ui/langgraph, wired into every LangGraph TypeScript demo and routed through dojo. Each compiled graph registers an aguiTransformer that translates LangGraph ProtocolEvents into AG-UI events on a custom:agui channel; LangGraphAgent subscribes via client.threads.stream(...) and forwards them into the run Observable. The legacy SSE translation stays in place behind useTransformer: false for non-langgraph integrations.

What changed

Transformer (integrations/langgraph/typescript/src/transformer/agui-transformer.ts)

  • Lifecycle: RUN_STARTED / RUN_FINISHED are owned by agent.ts; the transformer forwards root lifecycle.failed as RUN_ERROR and flushes snapshots on root completed and interrupted.
  • Messages: content-block-start / -delta / -finish for text, tool_call_chunk / tool_call, reasoning (+ Anthropic legacy thinking), and redacted_thinking (+ reasoning signature) → AG-UI TEXT_MESSAGE_*, TOOL_CALL_*, REASONING_*, REASONING_ENCRYPTED_VALUE. Tool-call args are diffed against a cumulative buffer so AG-UI sees true deltas. Reasoning + text can share a protocol index — finish events dispatch by block type, and a first text-delta at an occupied index implicitly opens a text block (closed on message-finish).
  • Snapshots: cache root values events via shallow merge so later partial values don't drop unchanged keys; flush exactly one STATE_SNAPSHOT + one MESSAGES_SNAPSHOT at root terminal.
  • Interrupts: emit CUSTOM OnInterrupt from either input.requested events or any task's interrupts: [] array (v3 dev server surfaces HITL via tasks, not input.requested).
  • Steps: non-root lifecycle events become STEP_STARTED / STEP_FINISHED keyed by namespace; any active steps are closed in finalize().
  • Custom channel: ManuallyEmitMessage → text events; ManuallyEmitToolCall → tool-call events; ManuallyEmitState → cache merge + immediate STATE_SNAPSHOT; everything else forwards verbatim as CUSTOM.

Agent (integrations/langgraph/typescript/src/agent.ts)

  • useTransformer flag opts a LangGraphAgent into the v3 path.
  • Per-thread ThreadStream + persistent custom:agui SubscriptionHandle cached across clone()s. The persistent sub is attached before the first run so server-side record.queuedEvents replay never lands on it. Pause/resume bracket each run (submitRun's #prepareForNextRun auto-resumes).
  • submitRun (narrow lifecycle) + respondInput on resume, with streamingThread.interrupts and agentState.tasks as id/namespace sources.
  • Sanitiser strips tool_call content blocks from re-sent AI messages and drops response_metadata.output_version: "v1" to keep langchain-openai's Responses serializer from mistyping prior assistant text as input_text (OpenAI 400).
  • Regenerate branch skipped when command.resume is set.

Demos + dojo

  • aguiTransformer registered at compile time on every TS demo: agentic_chat, agentic_chat_multimodal, agentic_chat_reasoning, agentic_generative_ui, backend_tool_rendering, human_in_the_loop, multimodal_messages, predictive_state_updates, shared_state, subgraphs, tool_based_generative_ui.
  • apps/dojo/src/agents.ts routes every langgraph-typescript graph through the transformer path.
  • examples bumped to @langchain/openai@^1.4.5 (1.2.0 emitted no reasoning content blocks on v3); @ag-ui/langgraph bumped to @langchain/langgraph-sdk@^1.9.2 for client-side reconciliation between reasoning and text blocks.

Known gap (parked, confirmed upstream)

On OpenAI Responses with reasoning enabled, @langchain/openai 1.4.5's server-side AIMessage assembler drops text deltas that land on a content-block index already occupied by a reasoning block. Text streams on the wire correctly (transformer + UI render it live), but the persisted AIMessage ends up reasoning-only, so MESSAGES_SNAPSHOT replaces the streamed text with nothing once the run finishes. Verified by calling threads.getState directly — the missing text is in the persisted state, not in our cache. Tracking upstream; Anthropic (type: "thinking" content blocks) is expected to work end-to-end.

Test plan

  • Manual: agentic_chat end-to-end (text + frontend tool call) on the transformer path.
  • Manual: human_in_the_loop end-to-end including resume via respondInput and confirming OnInterrupt is sourced from tasks.interrupts[] when v3 doesn't fire input.requested.
  • Manual: agentic_chat_reasoning — reasoning deltas stream as REASONING_* events and the live text answer renders (known gap on snapshot replacement documented above).
  • Manual: STEP_STARTED/FINISHED pairs emitted for chat_node and process_steps_node in HITL run.
  • apps/dojo playwright langgraphTypescriptTests/* — pending an aimock-routed dojo + langgraph dev run; the transformer-mode dojo + langgraph dev currently hit real OpenAI in this branch's local setup.

Notes for reviewers

  • The transformer code is intended to be additive: legacy handleStreamEvents is left intact, and any deployment passing useTransformer: false still goes through the old SSE translator.
  • Per-thread caching of ThreadStream is load-bearing — without it, fresh sinks receive a server-side replay of prior-run lifecycle terminals and the SDK's terminal-pause path drops the live run's events.
  • dispatchEvent/Subscriber.next semantics for the transformer path are intentionally identical to legacy so downstream consumers (CopilotKit, dojo widgets) don't need branch-aware logic.

@ranst91
ranst91 requested a review from a team as a code owner May 11, 2026 17:07
@vercel

vercel Bot commented May 11, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
ag-ui-dojo Ready Ready Preview, Comment Jun 12, 2026 3:42pm

Request Review

@github-actions

github-actions Bot commented May 11, 2026

Copy link
Copy Markdown
Contributor

Python Preview Packages

Version 0.0.0.dev1785834271 published to TestPyPI.

Warning: These packages are built from contributor code that may not yet have been vetted for correctness or security. Install at your own risk and do not use in production.

Install with uv

Add the TestPyPI index to your pyproject.toml:

[[tool.uv.index]]
name = "testpypi"
url = "https://test.pypi.org/simple/"
explicit = true

Then install the packages you need:

# Core SDK
uv add 'ag-ui-protocol==0.0.0.dev1785834271' --index testpypi

# Integrations (each already depends on the matching ag-ui-protocol preview)
uv add 'ag-ui-langgraph==0.0.0.dev1785834271' --index testpypi
uv add 'ag-ui-crewai==0.0.0.dev1785834271' --index testpypi
# NOTE: ag-ui-agent-spec depends on pyagentspec (git-only, not on PyPI).
# You will need to install pyagentspec separately from its git repo.
uv add 'ag-ui-agent-spec==0.0.0.dev1785834271' --index testpypi
uv add 'ag_ui_adk==0.0.0.dev1785834271' --index testpypi
uv add 'ag_ui_strands==0.0.0.dev1785834271' --index testpypi

Install with pip

pip install \
  --index-url https://test.pypi.org/simple/ \
  --extra-index-url https://pypi.org/simple/ \
  ag-ui-protocol==0.0.0.dev1785834271

Use --extra-index-url https://pypi.org/simple/ so pip can resolve
transitive dependencies (pydantic, fastapi, etc.) from real PyPI.


Commit: e8cbf62

@pkg-pr-new

pkg-pr-new Bot commented May 11, 2026

Copy link
Copy Markdown

Open in StackBlitz

@ag-ui/a2a-middleware

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/a2a-middleware@1651

@ag-ui/a2ui-middleware

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/a2ui-middleware@1651

@ag-ui/event-throttle-middleware

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/event-throttle-middleware@1651

@ag-ui/mcp-apps-middleware

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/mcp-apps-middleware@1651

@ag-ui/mcp-middleware

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/mcp-middleware@1651

@ag-ui/a2a

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/a2a@1651

@ag-ui/adk

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/adk@1651

@ag-ui/ag2

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/ag2@1651

@ag-ui/agno

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/agno@1651

@ag-ui/aws-strands

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/aws-strands@1651

@ag-ui/claude-agent-sdk

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/claude-agent-sdk@1651

@ag-ui/claude-managed-agents

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/claude-managed-agents@1651

@ag-ui/crewai

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/crewai@1651

@ag-ui/langchain

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/langchain@1651

@ag-ui/langgraph

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/langgraph@1651

@ag-ui/llamaindex

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/llamaindex@1651

@ag-ui/mastra

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/mastra@1651

@ag-ui/pydantic-ai

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/pydantic-ai@1651

@ag-ui/vercel-ai-sdk

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/vercel-ai-sdk@1651

@ag-ui/watsonx

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/watsonx@1651

@ag-ui/a2ui-toolkit

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/a2ui-toolkit@1651

create-ag-ui-app

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/create-ag-ui-app@1651

@ag-ui/client

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/client@1651

@ag-ui/core

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/core@1651

@ag-ui/encoder

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/encoder@1651

@ag-ui/proto

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/proto@1651

commit: f31bb0f

ranst91 added 17 commits May 27, 2026 19:43
Adds an opt-in v3-protocol path to @ag-ui/langgraph that wires an
AG-UI StreamTransformer at graph compile time and exposes events on
custom:agui. agent.ts subscribes via ThreadStream and forwards them
into its run Observable. Legacy translation stays in place; opt-in
via LangGraphAgent({useTransformer: true}) per-agent.

Currently scoped to the agentic_chat TS demo (single-turn text +
frontend tool calls + state/messages snapshots). Other event families
(interrupts, reasoning, steps, custom passthrough) land in follow-up
phases.

Notable transport detail: caches one ThreadStream + one persistent
custom:agui SubscriptionHandle per threadId across clone()s. Pause/
resume bracket each run instead of close/reopen, which avoids the
LangGraph API server replaying its per-thread queuedEvents (including
prior runs' lifecycle terminals) to a fresh sink — that replay would
otherwise trip the SDK's terminal-pause path and drop all events on
the second run.
Adds Phase 3.5: interrupt handling on the v3 transformer path, plus
the human_in_the_loop demo joins the transformer-enabled set in dojo.

Server-side (transformer):
- Forward `input.requested` events as AG-UI CUSTOM `OnInterrupt`
  (legacy contract; matches dojo's `useLangGraphInterrupt`).
- v3 protocol surfaces `interrupt(...)` calls as `tasks` events with
  an `interrupts: [...]` array on the task result and a root
  lifecycle terminal of `completed`, NOT as `input.requested`. Scan
  `tasks` events for these and emit `OnInterrupt` keyed by id.
- Flush snapshots on `lifecycle.interrupted` in addition to
  `completed` so interrupt boundaries get current state.
- Switch `cacheState` from replace to shallow merge — subsequent
  root `values` events on the same run can carry only the keys that
  just changed; replacing wholesale was shipping an empty
  MESSAGES_SNAPSHOT and resetting the dojo UI.

Client-side (agent.ts):
- Skip the regenerate branch when `command.resume` is set: HITL
  resume is not a fork-from-checkpoint.
- Resume routes through `streamingThread.respondInput(...)` instead
  of `submitRun(...)`. interrupt id/namespace come from the live
  `streamingThread.interrupts` array first, with an `agentState.tasks`
  fallback for cold-start ThreadStream cache misses.

Demo wiring:
- `examples/src/agents/human_in_the_loop/agent.ts` registers
  `aguiTransformer` at compile time.
- `apps/dojo/src/agents.ts` adds `human_in_the_loop` to
  `transformerEnabled`.
…ing demos on v3 transformer

Transformer (server-side):
- Forward content-block-start `type: "reasoning"` (standardized v3)
  and `type: "thinking"` (legacy Anthropic alias) to AG-UI
  REASONING_START + REASONING_MESSAGE_START + initial content;
  tracked per content-block index.
- Forward `reasoning-delta` / `thinking-delta` to REASONING_MESSAGE_CONTENT.
- Dispatch content-block-finish by the FINISHING block's `type`
  rather than by tracker-presence, so reasoning + text that share a
  protocol index don't bleed into each other's END events.
- Anthropic `redacted_thinking` and reasoning-block `signature`
  surface as REASONING_ENCRYPTED_VALUE.
- Implicit text-block open: the server emits text-deltas at an
  index already occupied by a reasoning block, without a preceding
  content-block-start of type=text. Treat the first such text-delta
  as an implicit open and close it on message-finish (server also
  omits the matching content-block-finish).
- Forward `input.requested` events as CUSTOM `OnInterrupt`, plus
  scan `tasks` events for any `interrupts: [...]` array on the task
  result and emit `OnInterrupt` per id (the v3 dev server surfaces
  HITL interrupts through `tasks` rather than `input.requested`,
  with the root lifecycle terminal still reported as `completed`).
- Snapshot flush also fires on lifecycle `interrupted` so HITL
  boundaries get current state.
- cacheState shallow-merges instead of replacing — later root
  `values` events sometimes carry only the changed keys; replacing
  wholesale was shipping empty messages snapshots and resetting the
  UI.

Agent (client-side):
- Skip the regenerate branch when `command.resume` is set — HITL
  resume is not a fork-from-checkpoint.
- Resume routes through `streamingThread.respondInput(...)` instead
  of `submitRun(...)`. Interrupt id/namespace come from the live
  `streamingThread.interrupts` array, with an `agentState.tasks`
  fallback for cold-start ThreadStream cache misses.
- Sanitizer for prior assistant messages now strips `tool_call`
  content blocks AND drops `response_metadata.output_version: "v1"`.
  The v1 flag activated langchain-core's `contentBlocks` path,
  which then had langchain-openai's Responses serializer mistype
  prior assistant text blocks as `input_text` (OpenAI 400).
  Dropping the flag falls back to the legacy content-array path
  that the Responses API accepts.

Demo wiring:
- examples/src/agents/human_in_the_loop/agent.ts and
  examples/src/agents/agentic_chat_reasoning/agent.ts register
  `aguiTransformer` at compile time.
- apps/dojo/src/agents.ts adds both to the transformer-enabled set.
- examples bumped to `@langchain/openai@^1.4.5` — 1.2.0 emitted no
  reasoning content blocks on v3; 1.4.5 does.
- @ag-ui/langgraph bumped to `@langchain/langgraph-sdk@^1.9.2` for
  client-side block-index reconciliation between reasoning and text.

Known gap (confirmed upstream): on OpenAI Responses with reasoning
enabled, langchain-openai 1.4.5's server-side AIMessage assembler
drops text deltas that land on a content-block index already
occupied by a reasoning block. Text streams correctly on the wire
(rendered live by the UI) but the assembled AIMessage persisted to
state ends up reasoning-only, so MESSAGES_SNAPSHOT replaces the
streamed text with nothing once the run finishes. Verified by
calling threads.getState directly — the missing text is upstream,
not in our cache.
Non-root lifecycle events bracket individual Pregel nodes. Translate
them into AG-UI STEP_STARTED / STEP_FINISHED so consumers can show
progress on multi-node graphs (e.g. the human-in-the-loop graph's
chat_node → process_steps_node transitions).

The namespace head is `nodeName:taskUuid`; strip the uuid for a
readable step name. Active steps are keyed by the full namespace
path so parallel tasks for the same node don't unbalance the
STEP_STARTED/STEP_FINISHED pairs that AG-UI's verify enforces.
Any steps still open at run end are closed in `finalize()`.
… all TS demos

Transformer:
- Handle the v3 `custom` channel. Branch on data.name:
  - ManuallyEmitMessage → TEXT_MESSAGE_START/CONTENT/END
  - ManuallyEmitToolCall → TOOL_CALL_START/ARGS/END
  - ManuallyEmitState → merge payload into cached state, ship an
    immediate STATE_SNAPSHOT, fall through to generic CUSTOM
    passthrough so listeners that key off the name still receive it
  - everything else → generic CUSTOM forward (preserves the legacy
    `value: event.data` contract)

Demos: register `aguiTransformer` at compile time for every TS demo
(agentic_chat_multimodal, agentic_generative_ui, backend_tool_rendering,
multimodal_messages, predictive_state_updates, shared_state,
subgraphs, tool_based_generative_ui). agentic_chat, human_in_the_loop,
agentic_chat_reasoning were already wired earlier.

dojo: route all langgraph-typescript demos through the transformer
path (`useTransformer: true`). The legacy translation in agent.ts
remains in place but is no longer reachable from langgraph-typescript;
non-langgraph deployments continue to use it via `useTransformer:
false`.
…subgraph lifecycles don't unbalance pairs

AG-UI's verify enforces at most one active step per stepName. Previously
we deduped by full namespace key, so a subgraph node and its inner
graph node (both rooted under `experiences_agent:...`) each opened a
STEP_STARTED with the same stepName, and verify rejected the second:

  Error: Step "experiences_agent" is already active for 'STEP_STARTED'

Track active step names alongside the namespace map. The first
namespace to open a stepName wins; deeper nested lifecycles whose
stripped head collides are ignored until that step closes. The
matching STEP_FINISHED still fires when the originating namespace's
lifecycle terminates, so outer-vs-inner ordering stays balanced.
…, flush snapshots at node boundaries

Constructor `useTransformer` now defaults to `true` but honors an
explicit `false` from callers — every demo opted in by passing the
flag, but the unit tests in `subgraph-streaming.test.ts`,
`predict-state-e2e.test.ts`, and `messages-tuple.test.ts` synthesize
legacy events-mode chunks and need `handleStreamEvents` to run, so
they now construct their agents with `useTransformer: false`.

Transformer: also flush `STATE_SNAPSHOT` + `MESSAGES_SNAPSHOT` when a
non-root lifecycle event terminates with `completed`. A subgraph (or
any node) can produce many intermediate `values` updates; locking in
a single hash-deduped snapshot at the node/subgraph boundary gives
consumers a coherent view of state as soon as that node's contribution
is committed to the parent checkpoint, rather than waiting until the
root run terminates.
…-stream routes

1.1.13 lacked POST /threads/:tid/commands and /threads/:tid/stream/events,
so the AG-UI transformer path returned 404 in the langgraph-typescript
e2e job. 1.2.1 ships the v3 protocol surface that ThreadStream uses.
…fresh header doc

Removed dead state (`runStartedEmitted`, `runFinishedEmitted`,
`runErrorEmitted`, `ensureRunStarted`) — leftover from when the
transformer owned RUN_STARTED/RUN_FINISHED before that responsibility
moved to `agent.ts`. Refreshed the file header to describe the
event-family coverage that's actually shipped, dropping the
phase-numbered TODO list and the stale "loose dictionary" comment.
…actor targets

Two new test files capture the target shape of the upcoming refactor
work — they're committed red so the implementation pass can drive them
green.

prepare-stream.test.ts:
- sanitizeAssistantMessages (named export from ./agent) is a pure
  helper: tool_call content blocks stripped from AI messages,
  response_metadata.output_version 'v1' dropped, sibling keys and
  non-AI messages preserved, no-throw on missing fields.
- transformerThreads cache shared across clone()s — second
  prepareStream call on the same threadId reuses the cached
  ThreadStream and its persistent custom:agui subscription instead of
  re-subscribing.
- Resume routing — when forwardedProps.command.resume is set and a
  pending interrupt is reachable (live streamingThread.interrupts or
  agentState.tasks fallback), the agent calls respondInput; otherwise
  submitRun. No-interrupt resume falls through to submitRun.

prepare-regenerate-stream.test.ts:
- useTransformer=true: regen runs through the cached ThreadStream's
  submitRun({ forkFrom: { checkpointId } }) — no client.runs.stream
  call, one custom:agui subscribe.
- useTransformer=false: legacy client.runs.stream path is preserved
  (backwards-compatible fallback when transformer wiring isn't
  available).
…op-level helper

Moves the inline assistant-message sanitizer out of `prepareStream`
and exports it as a top-level pure function:

- Strips `tool_call` content blocks from re-sent AI messages
  (CopilotKit replays them on the wire; langchain 1.4 + OpenAI reject).
- Drops `response_metadata.output_version: "v1"` so langchain-core's
  v1 contentBlocks path doesn't route prior text through
  langchain-openai's Responses serializer (mistypes as `input_text`).

Behavior is identical to the previous inline implementation; the
extraction is a precondition for the upcoming unit tests of the
sanitizer in isolation and for sharing it with
`prepareRegenerateStream`.
… three private helpers

`prepareStream`'s transformer path is now an orchestrator (~30 lines)
on top of three private helpers, all behavior-preserving:

- `acquireTransformerThread(threadId)` — get-or-create the cached
  `(ThreadStream, custom:agui SubscriptionHandle)` pair. The sub is
  opened once and reused across every run on the thread so server-side
  replays never land on a fresh sink.
- `findPendingInterrupt(thread, agentState, resume?)` — resolves
  which interrupt to resume against; live `thread.interrupts` first,
  then `agentState.tasks` fallback for ThreadStream-cache cold starts.
- `watchForRootTerminal(thread, sub)` — registers the per-run
  onEvent listener that pauses the persistent sub when the root
  lifecycle terminates; returns its unsubscribe.

Casts remain `any` for now — type tightening lands in the next
commit so this change reads as pure extraction.
…ts, use SDK exports

Imports `ThreadStream` and `SubscriptionHandle` from
`@langchain/langgraph-sdk` and introduces a `TransformerThreadEntry`
interface to type the per-thread cache and the three private helpers
extracted in the previous commit.

Casts removed:
- `Map<string, { thread: any; aguiSub: any }>` →
  `Map<string, TransformerThreadEntry>`.
- `(thread as any).subscribe(...)` → typed handle; result narrowed to
  `SubscriptionHandle<any, ProcessedEvents>` (the named-custom unwrap
  yields ProcessedEvents payloads).
- `(streamingThread as any).onEvent(...)` → typed call; event payload
  narrowed inside the handler.
- `(aguiSub as any)?.pause?.()` → `aguiSub.pause()` on the typed
  handle.
- `(streamingThread as any).submitRun(...)` /
  `.respondInput(...)` → typed call sites.
- `streamResponse: aguiSub as any` → returns the typed handle directly.
- `(stream as any)?.close?.()` in the run handler → narrow cast to
  `{ close?: () => void | Promise<void> } | undefined` so the optional
  closer is invoked only when present.

No behavior change; this is the type pass on top of the helper split.
…rity

When `useTransformer` is enabled, regenerate now reuses the cached
ThreadStream + custom:agui subscription via
`streamingThread.submitRun({ ..., forkFrom: { checkpointId } })` —
the v3 protocol primitive for forking a new run from an explicit
checkpoint. The sanitizer is applied to the regen input too, matching
`prepareStream`.

When `useTransformer` is disabled (or `streamingThread` can't be
acquired), the existing legacy `client.runs.stream(...)` path is
preserved verbatim — backwards-compatible fallback for callers that
haven't opted into the transformer path.

Closes the last gap where the regen flow bypassed the transformer
and went through legacy translation regardless of agent config.
…west-first getHistory order

`threads.getHistory` returns checkpoints newest-first;
`getCheckpointByMessage` reverses to walk oldest-first and finds the
first checkpoint containing the target message. The previous fixture
ordered ck-old before ck-new, so after reverse the search found ck-new
(which had both u1 and a1), saw `messagesAfter` non-empty, and
recursed on the parent — but the mock returned the same list every
time, so the search never terminated and the worker timed out.

Swap to newest-first so the reversed walk lands on ck-old (only u1, no
messagesAfter) and returns immediately.
@ranst91
ranst91 force-pushed the claude/beautiful-curran-d22bd3 branch from cbf4139 to c62cd60 Compare May 27, 2026 17:47
ranst91 added 9 commits July 20, 2026 18:32
…2 fallback

The OPTIONS probe (supportsV3) was signal-free: the langgraph-cli dev
server answers every OPTIONS with 204 (blanket CORS preflight) on every
path — real route, fake path, v2, v3 alike — and a plain GET on the v3
WebSocket route 404s even where v3 exists. So the probe always picked v3;
against a v2-era server the real v3 request then 404'd and the run died
instead of falling back to the working v2 path.

Remove the probe entirely. Detect at run time instead: attempt the real
v3 subscribe/submitRun and, if the v3 route is absent, it throws a 404 at
subscribe (in acquireTransformerThread) BEFORE any AG-UI event is emitted
— catch it and fall back to the legacy client.runs.stream path. A missing
route 404 is an HTTP fundamental, not a version-specific quirk, so this is
robust across LangGraph versions.

- shouldAttemptV3(): skip v3 only once memoised as v2
- isV3UnsupportedError(): a v3-attempt error means "no v3 route" (404)
- lenient fallback: any v3-attempt error routes this run to v2; only a
  definitive 404 is memoised as permanent v2 (transient errors retry v3)
…rate

Drop the obsolete fetch/OPTIONS stub; the mock ThreadStream now rejects
subscribe with a 404 for the v2 case, exercising the runtime fallback to
client.runs.stream. Assert the v3 run is not submitted when it falls back.
Fix six confirmed AG-UI event-grammar bugs in the StreamTransformer, all
covered by a new agui-transformer.test.ts (red before, green after):

- message-error now closes every open text/tool/reasoning block instead of
  clearing the map, so no dangling START survives into finalize().
- each text content-block gets a distinct message id (bare id for the first
  block, suffixed for the rest) to avoid duplicate TEXT_MESSAGE_START for one id.
- message-finish closes still-open tool/reasoning blocks so a later message's
  same-index block can't overwrite them and drop their END.
- TOOL_CALL_START is deferred until the tool name is known (buffering args),
  so it never goes out with a knowingly-empty name that arrives later.
- block-delta buffer-replace emits only the post-common-prefix delta instead
  of re-sending the full args on top of the already-streamed prefix.
- bare interrupt values coerce to a string ("null") at both emit sites, and
  input.requested now dedups by interrupt id like the tasks path.
…tractors

- types.ts: RunMetadata.isV3 docstring now describes the runtime
  v3->v2 subscribe-404 fallback instead of the removed OPTIONS probe
- extractors.ts: guard params/data deref with optional chaining so a
  malformed lifecycle chunk yields false instead of throwing; rename the
  misleading `chunkData` param to `event` (callers pass the whole
  streamResponseChunk envelope); drop stale "TODO: is it needed?"
- prepare-stream.test.ts: remove the dead OPTIONS-probe fetch stub and
  its misleading comment (v3 path is driven by the mock ThreadStream)
- apps/dojo/agents.ts: fix comment to describe runtime v3->v2 fallback
Fixes 8 verified code-review findings in @ag-ui/langgraph agent.ts:

1. isV3UnsupportedError now requires BOTH a protocol-request marker AND a
   404, and is consulted ONLY at the v3 subscribe step — an unrelated REST
   404 or a transient 5xx no longer wrongly memoises the shared v3Support
   holder to permanent v2.
2. prepare*Stream v3 blocks release the watchForRootTerminal lifecycle
   listener when submit/respondInput throws (was leaked).
3. An interrupt-without-resume turn marks the run settled so runAgentStream
   no longer errors the already-completed subscriber.
4. handleStreamEventsV2 gains the runErrored guard: no STATE/MESSAGES
   snapshot or RUN_FINISHED after RUN_ERROR.
5. v3 resume derives resumeRequested from BOTH input.resume[] and legacy
   command.resume, and respondInput carries the resolved resume value — a
   canonical input.resume[] now routes to respondInput.
6. v3 run-end interrupt routes through dispatchInterruptFinish so the
   interrupt-outcome contract is honored identically to v2.
7. Transformer-mode interrupts are no longer double-rendered (suppressLegacy).
8. handleSingleEventV3 stream-balance: message-error/message-finish close
   open text/reasoning/tool blocks; multiple text blocks under one message
   id dedup to one START/END; deferred tool name yields a single named
   TOOL_CALL_START.

Also: submit/respondInput failures surface as real run errors instead of
silently falling back; drop the dead eventType==='completed' branch and
stale OPTIONS-probe / Parity-with-Python comments. Adds red-green tests.
…t.ts

Confirmed via a multi-round adversarial review of the LangGraph to AG-UI
event translation.

- v3 raw loop translates custom-channel ManuallyEmit* events
  (handleCustomEventV3) instead of silently dropping them; the v2 path and
  the transformer already handled these.
- v3 tool-call args deltas use a longest-common-prefix diff so consumers
  that accumulate deltas never receive the full cumulative string.
- interrupt-only fast path closes the step opened by handleNodeChange
  before RUN_FINISHED, keeping STEP_STARTED/STEP_FINISHED balanced.
- reasoning process opened at index 0 is closed on index change (the guard
  was a truthiness test that skipped index 0).
- messages-tuple fallback closes the open block on any terminal
  finish_reason (tool_calls and length previously left it open).
- getCheckpointByMessage throws a clear error instead of recursing
  unbounded when a checkpoint has no parent.
- v2 mid-run state-diff snapshot compares against a pre-mutation
  serialization (updatedState aliased and mutated state in place, so the
  diff never fired).
- continue mode is driven from forwardedProps.nodeName (activeRun.nodeName
  is unset at prepare time, matching the Python SDK); the dead, never-read
  shouldExit tracking is removed.
- optional chaining added on OnChatModelStream response_metadata, and the
  acquireTransformerThread channel-list comment corrected.

Adds unit tests covering each case.
…up ManuallyEmitState snapshot

- On a root `failed` lifecycle event, close open message blocks and steps
  before emitting RUN_ERROR and drop any later events, so no END or
  STEP_FINISHED trails the error (matching the message-error path).
- ManuallyEmitState records lastStateSnapshotHash so the root-completed
  flush does not re-emit an identical STATE_SNAPSHOT.

Adds transformer tests.
…ph devDependency

- Rewrite the isMessageTupleEvent comment to describe the v3 message_tuple
  data type rather than the legacy SSE stream-mode name.
- Add @langchain/langgraph to devDependencies so isolated installs resolve
  the peer that src imports (it previously resolved only via pnpm hoisting).
@ranst91
ranst91 force-pushed the claude/beautiful-curran-d22bd3 branch from a8c6a07 to 779c2a7 Compare July 21, 2026 17:55
ranst91 and others added 19 commits July 22, 2026 08:34
…e transformer subpath exists

The dojo langgraph-typescript e2e imports @ag-ui/langgraph/transformer, but
the examples pinned the published @ag-ui/langgraph 0.0.41, whose exports map
predates the transformer subpath, so the dev server failed to boot with
ERR_PACKAGE_PATH_NOT_EXPORTED and the e2e timed out waiting on port 8006.

Add integrations/langgraph/typescript/examples to the pnpm workspace and
switch its @ag-ui/langgraph dependency to workspace:*, mirroring the mastra
and aws-strands examples, so it resolves the local build that ships the
./transformer export.
…ld so the transformer subpath exists"

This reverts commit 3277843.
…rent langgraph API

The dojo langgraph-typescript e2e imports @ag-ui/langgraph/transformer, but
the examples pinned the published 0.0.41 (which has no transformer subpath)
and used moduleResolution "node", so the dev server failed to boot and the
examples never type-checked against the current dependencies.

- Add integrations/langgraph/typescript/examples to the pnpm workspace and
  depend on @ag-ui/langgraph via workspace:*, so it resolves the local build
  that ships the ./transformer subpath (mirrors the mastra and aws-strands
  examples).
- Switch the examples tsconfig to moduleResolution "bundler" (target ES2022)
  so the subpath types resolve the same way the langgraph-cli/esbuild bundler
  does at runtime.
- Migrate the StateGraph builders to the chained addNode/addEdge API the
  current @langchain/langgraph typings require to track node names, and drop
  the explicit <AgentState> generic that broke the annotation overload
  (shared_state, human_in_the_loop, predictive_state_updates, subgraphs,
  tool_based_generative_ui).
- Drop the thinkingBudget option, which @langchain/google-genai 0.2.18 no
  longer exposes (Gemini 2.5-pro still returns reasoning by default).
…tion

apps/dojo/src/files.json embeds each example agent's source. Regenerated so
it reflects the migrated langgraph-typescript examples (chained StateGraph
builders and the dropped thinkingBudget option), keeping the
check-generated-files CI job in sync.
…prep

Now that integrations/langgraph/typescript/examples is a pnpm workspace
member, the parallel dojo build step regenerates package.json exports across
the workspace (tsdown exports generation), so a frozen install for this
example can fail with ERR_PNPM_OUTDATED_LOCKFILE on that transient drift.
Mirror the mastra example prep, which already uses --no-frozen-lockfile.
…kspace:* resolves

The examples directory declared its own single-package pnpm workspace
(packages: ['.']). With that file present, `pnpm install` run from the
examples directory rooted the workspace at examples itself, so the sibling
@ag-ui/langgraph package was not a member and @ag-ui/langgraph@workspace:*
failed with ERR_PNPM_WORKSPACE_PKG_NOT_FOUND. Removing it lets pnpm resolve
the monorepo root workspace (which now includes both the package and the
examples), matching the mastra example, which has no local workspace file.
…ce membership

The examples import @ag-ui/langgraph/transformer, which only the local build
ships, but pinned the published 0.0.41 that predates the subpath. My earlier
fix made the example a pnpm workspace member (workspace:*), which forced its
dependencies onto the monorepo-hoisted langchain 1.2.32. That version added a
strict wrapModelCall check that CopilotKit 1.57.1's middleware violates
("expected AIMessage or Command, got object"), breaking every A2UI demo agent
at runtime — a regression the workspace conversion introduced.

Revert to the example's original isolated workspace (its own pnpm-workspace
and langchain ^1.2.3 pin, which resolves 1.2.8 as on main) and change only the
@ag-ui/langgraph dependency from the 0.0.41 registry pin to link:.., so it uses
the local build's ./transformer export without disturbing any other dependency.
The local package is built ahead of the e2e run as a demo-viewer nx build
dependency. This also reverts the now-unneeded StateGraph migration, bundler
tsconfig, prep --no-frozen-lockfile, and files.json regeneration those changes
required; net change versus main is a single dependency line.
…L_RESULT

LangGraph 1.3's `tools` stream channel reports `tool-finished.output` as the
ToolNode result envelope `{ status, content }`. handleToolsEventV3 stringified
that whole envelope into TOOL_CALL_RESULT.content, so consumers that parse the
content directly saw `{"status":"success","content":"..."}` instead of the
inner ToolMessage string. This broke the A2UI demos: the A2UI middleware reads
top-level `a2ui_operations` and cannot see into a `content` wrapper, so no
surface rendered (Playwright: Element not found [data-surface-id=...]). The v2
path emits the raw `toolCallOutput.content`, which is why main (v2) is green.

Unwrap the envelope to the inner content (with the same array-block flattening
the v2 path uses) so the v3 TOOL_CALL_RESULT carries the same raw string. Adds
unit tests for the envelope and array-content cases.
… transformer

OpenAI Responses reasoning (o4-mini/gpt-5) is delivered on the assistant
message's additional_kwargs.reasoning ({ id, summary: [{ text }] }), NOT as a
`messages`-channel content block. Verified against real gpt-5 and aimock: the
v3 messages channel carries only text blocks (though usage reports reasoning
tokens), while the reasoning summary rides on additional_kwargs of the
values-channel message. The transformer only unpacked reasoning content
blocks, so the reasoning summary was dropped and the "Thought for …" indicator
never rendered through the transformer (the v2 path reads it via
resolveReasoningContent, which is why main is green).

Surface it: when flushing the state, emit a REASONING entity from each
assistant message's additional_kwargs.reasoning.summary (deduped by reasoning
id, and skipped when a messages-channel reasoning block already streamed so it
is never emitted twice). Adds transformer unit tests for the emit, empty-summary,
and dedup cases.
The streamed reasoning message and the MESSAGES_SNAPSHOT copy were minted
under different ids: the v3 client and the transformer used
`${messageId}:r:${index}`, while the snapshot converter
(`reasoningBlockToAguiMessage`, utils.ts) uses `${messageId}-reasoning-${index}`.

When the snapshot carries reasoning, @ag-ui/client applies replace semantics
(a snapshot that contains reasoning is the source of truth), so the streamed
copy was dropped and the snapshot copy appended: the reasoning indicator
disappeared the moment the final snapshot landed.

Prefer the provider's canonical reasoning id (e.g. OpenAI `rs_…`) when the
content block carries one, and otherwise fall back to the exact formula the
snapshot converter uses, so the two reconcile in place. Adds regression tests
for both the canonical-id and fallback-id cases on both paths.
…mock to match

@langchain/openai 1.2.x delivers OpenAI Responses reasoning only on the
message's `additional_kwargs`, which the v3 protocol does not transport: the
messages channel carries content blocks, so reasoning never reached the client
or the transformer and the reasoning demo rendered no thinking. 1.5.x parses
reasoning into standard content blocks, which v3 already transports, so both
the raw v3 path and the transformer path receive it with no agent-side wiring.

- examples: @langchain/openai ^1.2.0 -> ^1.5.5, @langchain/core ^1.1.44 ->
  ^1.2.3 (1.5.5 imports a core subpath added in 1.2), and declare
  @langchain/langgraph, which the agents import directly and which previously
  resolved only by hoisting.
- dojo: @copilotkit/aimock ^1.9.0 -> ^1.37.4. The pinned 1.11.0 omits
  `annotations` on Responses output_text parts, which crashes @langchain/openai
  1.5.x while parsing the mocked stream.
- dojo: @copilotkit/* 1.61.2 -> 1.63.2.
…ture

The reasoning fixture was hand written. Re-record it from a real gpt-5.4 call
made with the demo's own configuration (same system prompt, reasoning effort
high, summary auto), so the mocked reasoning summary and answer match what the
model actually returns. Switch the demo's default OpenAI model to gpt-5.4 to
match, and regenerate the dojo files.json that embeds the agent source.
uv.lock pinned ag2 0.11.0 while pyproject required >=0.11.1, so the lock was
inconsistent and `uv sync` re-resolved instead of installing the locked
version. That silently tracked the newest release, which was harmless until
ag2 1.0.0 shipped (2026-07-27) and dropped the `autogen` module: the dojo ag2
e2e suite then failed with ModuleNotFoundError before serving a request.

Cap the requirement below the breaking major and regenerate uv.lock so it
satisfies pyproject, pinning ag2 0.14.0. Verified `import autogen` succeeds in
the synced environment. Unrelated to this PR's transformer work, but it blocks
CI.
Port the server-side AG-UI StreamTransformer from the TypeScript integration
so Python graphs can compile it in via `graph.compile(transformers=[...])` and
have their protocol events translated to AG-UI events server side, consumed by
the existing client transformer-passthrough path.

The langgraph Python stream-transformer API (`langgraph.stream`, and the
`transformers` argument to `compile()`) landed in langgraph 1.2, while this
package supports `langgraph>=0.6.0,<2`. Rather than raise the floor, the
transformer imports `langgraph.stream` lazily and raises an actionable error
when the installed langgraph predates 1.2, so importing ag_ui_langgraph keeps
working on older versions and only using the transformer requires 1.2.

Ports the invariants the TypeScript implementation established: balanced
START/END for text, tool, reasoning and step events across message-finish and
message-error; nothing emitted after a terminal RUN_ERROR; the provider's
canonical reasoning id with the `{message_id}-reasoning-{index}` fallback that
reconciles with the MESSAGES_SNAPSHOT copy; ToolNode result-envelope unwrapping;
longest-common-prefix tool-arg deltas; snapshot dedup; ManuallyEmit* translation
and interrupt dedup.

Two divergences are forced by the Python stream API: `lifecycle` events never
reach transformers (the mux forwards channel pushes without re-entering the
pipeline), so step bracketing is derived from the raw `tasks` stream and the
terminal snapshot flush happens in `finalize()`; and consequently the RUN_ERROR
path is dormant in-process, which is consistent with the agent owning terminal
lifecycle events.

Bump the dev/CI lock to langgraph 1.2.9 so these tests actually execute in CI
instead of skipping. The published floor is unchanged, and the examples lock
used by the dojo e2e is untouched.
…an-d22bd3

# Conflicts:
#	apps/dojo/package.json
#	integrations/ag2/python/examples/uv.lock
#	pnpm-lock.yaml
The merge resolution took main's lockfile and then reconciled it with
`pnpm install`, but the post-install lockfile was never restaged, so the merge
commit shipped main's lock without this branch's @langchain/langgraph
devDependency entry. CI installs with --frozen-lockfile and failed immediately
with ERR_PNPM_OUTDATED_LOCKFILE.
The merge brought in upstream example sources whose contents files.json
embeds, leaving the committed copy stale and failing the
dojo / check-generated-files job.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant